Skip to content

First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin)#2910

Merged
maxisbey merged 21 commits into
v2-2026-07-28from
stateless-tools-call
Jun 20, 2026
Merged

First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin)#2910
maxisbey merged 21 commits into
v2-2026-07-28from
stateless-tools-call

Conversation

@maxisbey

@maxisbey maxisbey commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

First end-to-end slice of the 2026-07-28 stateless protocol, plus the interaction-suite era axis turned on so the existing transport-agnostic tests run at the new revision.

Part of #2891, #2892, #2893, #2894 (closes none).

Server

  • mcp.server._streamable_http_modernhandle_modern_request direct-invocation path (private)
  • StreamableHTTPSessionManager.handle_request branches on MCP-Protocol-VersionMODERN_PROTOCOL_VERSIONS
  • per-request ServerRunner over a SingleExchangeDispatcher (no back-channel; notifications no-op pending the SSE response mode)

Client

  • Client, ClientSession, and streamable_http_client() gain protocol_version: str | None
  • pinned to "2026-07-28": born initialized (synthesized InitializeResult, placeholder server_info pending server/discover); initialize() is idempotent; stamps the io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities} envelope on every request; cancel_on_abandon=False
  • pinned to a stateful version: initialize() requests that version instead of LATEST_PROTOCOL_VERSION
  • transport pins MCP-Protocol-Version from the constructor; Mcp-Method/Mcp-Name derived per message; _encode_header_value Base64-sentinel-wraps RFC-7230-unsafe names
  • MODERN_PROTOCOL_VERSIONS constant in mcp.shared.version

Types

  • CacheableResult defaults to ttl_ms=0, cache_scope="private" so list/read results constructed without explicit hints validate against the 2026-07-28 surface and never accidentally enable shared caching

Interaction suite

  • SPEC_VERSIONS = ("2025-11-25", "2026-07-28") — node ids gain a -version suffix
  • the connect fixture forwards protocol_version through Client; in-memory and streamable-http-stateless era-locked to 2025-11-25
  • [streamable-http-2026-07-28]: 60 pass, 8 xfail (xfails are progress/logging — handler-emitted notifications not yet streamed on the single-exchange path)
  • 13 lifecycle:* requirements get removed_in="2026-07-28"; 6 error-shape requirements excluded (modern-error-surface — tests pin the legacy code=0 divergence; the modern arm returns the spec-correct -32603)
  • the wire contract (envelope, headers, no handshake, no session-id) stays pinned by transports/test_hosting_http_modern.py

Conformance

  • 23 scenarios now pass at the 2026-07-28 wire across the draft and carried-forward legs
  • tools-call-with-progress stays expected-fail (progress delivery on the single-exchange path)

Behaviour changes

  • ClientSession.initialize() is now idempotent: a second call returns the cached result (or the synthesized one for a stateless pin) instead of re-sending the handshake. All other 2026 behaviour is opt-in via protocol_version=. SUPPORTED_PROTOCOL_VERSIONS and LATEST_PROTOCOL_VERSION are unchanged.

Flagged for review

  • # type: ignore[call-arg] in server/session.py for _related_request_id (TODO at :94 — fix is the ServerSession/Context rework)
  • # type: ignore[reportPrivateUsage] in the modern entry (_compose_on_request, _EXIT_STACK_CLOSE_TIMEOUT) — both fall out of the ServerRunner driver-split

Out of scope

server/discover, _meta validation ladder, version negotiation, subscriptions/listen, MRTR, SSE response mode for the modern entry, public design of the new HTTP path, in-memory modern entry.

AI Disclaimer

@maxisbey maxisbey force-pushed the stateless-tools-call branch from 89b1ed8 to d9eda60 Compare June 19, 2026 14:13
maxisbey added 11 commits June 19, 2026 15:07
Routes MCP-Protocol-Version: 2026-07-28 requests at the session-manager
seam to a new direct-invocation handler in mcp.server._experimental,
leaving the existing 2025-era paths (stateful and stateless_http)
unchanged.

The new handler builds a fresh per-request ServerRunner over a
single-exchange Dispatcher implementation (no memory streams, no
JSONRPCDispatcher), pre-commits the connection to 2026-07-28, runs the
composed on_request directly in the request task, and writes a JSON
response. Server-to-client requests raise NoBackChannelError;
notifications no-op pending SSE streaming.

Dispatcher annotations on ServerRunner/ServerSession widened from
JSONRPCDispatcher to the Dispatcher Protocol.

The module is experimental and not part of the public API.

Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
When ClientSession is constructed with protocol_version="2026-07-28",
each outgoing request carries the io.modelcontextprotocol/* envelope
(protocolVersion, clientInfo, clientCapabilities) in params._meta, and
initialize() raises if called. Capabilities derivation is extracted to
_build_capabilities() so both paths share it.

The streamable-HTTP transport derives MCP-Protocol-Version, Mcp-Method
and (for tools/call) Mcp-Name headers per POST from the body's envelope;
non-header-safe values are Base64-sentinel-encoded per the spec.
Envelope-less bodies get no derived headers, so unpinned behaviour is
unchanged. Session-id capture, the standalone GET stream and DELETE on
close are gated on traffic the pinned mode never produces.

Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
Drops the StreamableHTTPSessionManager dependency from the experimental
module; the handler only needs the lowlevel Server and the
TransportSecuritySettings.

Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
… path

- assert_no_modern_vocabulary helper and on_response= hook on mounted_app
- lowlevel/test_lifecycle_stateless.py: pinned ClientSession stamps the
  envelope on every request, initialize() is rejected, caller _meta
  survives the merge, unpinned sessions carry no 2026 vocabulary
- transports/test_legacy_wire.py: a 2025-era exchange carries no 2026
  vocabulary at the HTTP seam
- transports/test_client_transport_http_modern.py: body-derived header
  table and the Mcp-Name Base64-sentinel encoding
- transports/test_hosting_http_modern.py: stateless tools/call returns
  resultType complete, no Mcp-Session-Id, initialize is METHOD_NOT_FOUND
- transports/test_hosting_http.py: the Unsupported-protocol-version
  rejection literal stays sniffable

Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
…anifest

Eleven new entries: nine with added_in="2026-07-28" sourced from
SPEC_2026_BASE_URL, plus the two cross-era guard entries
(protocol-version-rejection-literal, legacy-no-modern-vocabulary). Each
transports-restricted entry carries a note per the manifest invariant.

Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
… path

- transports/test_client_transport_http_modern.py: pinned session POST
  carries body-derived headers on the wire; a returned session id is
  ignored and no GET/DELETE is sent
- transports/test_hosting_http_modern.py: non-2026 headers fall through
  to the legacy transport unchanged; handler exceptions map to
  INTERNAL_ERROR with a generic message; capstone end-to-end stateless
  tools/call (real ClientSession against the modern entry)
- tests/client/test_streamable_http.py: unit tests for
  _body_derived_headers and the _encode_header_value Base64-sentinel
  gate (private-helper coverage, kept out of the interaction suite)

Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
Adds client-transport:http:stateless-ignores-session-id,
hosting:http:modern:legacy-fallthrough and
hosting:http:modern:handler-exception-internal-error.

Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
Unit tests for SingleExchangeDispatcher (NoBackChannelError, no-op
notify, run() raises) and _SingleExchangeDispatchContext, plus
handle_modern_request edge paths (non-POST 405, malformed-body
PARSE_ERROR, transport-security rejection, ValidationError mapping).
One additional _body_derived_headers case covers the name-absent
branch.

Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
The modern entry now serves 13 carried-forward scenarios (tools/call,
prompts/get, completion, dns-rebinding) at the 2026-07-28 wire; remove
them from the 2026 baseline. List-result scenarios remain expected-fail
pending cacheScope/ttlMs defaults (SEP-2549).

input-required-result-validate-input is now baselined per the comment
that predicted it; input-required-result-ignore-extra-params now passes.

Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
- Parse-error response keeps the required "id": null member
- 405 carries the Allow: POST header
- exit_stack.aclose() is shielded, bounded, and exception-suppressed,
  matching ServerRunner.run()'s contract
- The success/error response is sent inside the per-request lifespan
  scope so a teardown error cannot drop an already-computed result
- Coverage tests for the cleanup-raises and cleanup-hangs arms
- Two no-branch pragmas for the 3.14 nested-async-with coverage quirk

Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
streamablehttp_client() and StreamableHTTPTransport now take
protocol_version: str | None = None, seeding the existing
self.protocol_version field that _prepare_headers() already reads.
_body_derived_headers (which sniffed params._meta) is replaced by
_per_message_headers, gated on the pin and reading message.method
directly so requests and notifications are handled uniformly.

The _meta envelope is request-only per spec and stays the session's
responsibility; the transport no longer treats the body as the source
of truth for connection-level headers. The constructor pin also wins
over the InitializeResult snoop.

ClientSession: pinned sessions set cancel_on_abandon=False so the
dispatcher never emits notifications/cancelled (a stateless server
cannot correlate it); the envelope keys now overwrite caller-supplied
_meta values rather than setdefault.

For now the pin is passed to both streamablehttp_client and
ClientSession; the high-level Client will collapse this to one
argument.

Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
@maxisbey maxisbey force-pushed the stateless-tools-call branch from d9eda60 to 06d1492 Compare June 19, 2026 15:39
The exit-stack-hangs test passes on all Python versions, but
coverage.py on 3.11 misreports the assertions after the shielded
move_on_after cancellation as unhit (the tracer in the test frame is
disrupted by the cancel inside the request task). lax no cover is the
sanctioned exclusion for lines covered on some versions but not others.

Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
@maxisbey maxisbey force-pushed the stateless-tools-call branch from fe1f248 to 4378d15 Compare June 19, 2026 15:55
Comment thread src/mcp/server/session.py
data["method"], data.get("params"), opts or None, _related_request_id=related
# TODO: _related_request_id is not on the Dispatcher Protocol; either
# add it there or refactor ServerSession once the legacy path is compat-only.
result = cast(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like we should use the dispatcher context object here then? that's the whole point of it, is it know it's own secret params

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it's the wrong shape. Reworded the TODO to point at the actual fix (a per-request Outbound — the DispatchContext — alongside the connection-level one, with related_request_id as the channel selector). That belongs with the ServerSession/Context rework; widening the Protocol is the wrong direction since related_request_id is transport-specific.

Comment thread src/mcp/server/session.py
"""Send a typed server-to-client notification."""
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
await self._dispatcher.notify(data["method"], data.get("params"), _related_request_id=related_request_id)
await self._dispatcher.notify(data["method"], data.get("params"), _related_request_id=related_request_id) # type: ignore[call-arg]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is bad

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above — see the reworded TODO at :94.

Comment thread src/mcp/client/session.py Outdated
return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots)

async def initialize(self) -> types.InitializeResult:
if self._pinned_version is not None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pinning to a stateful version is the contradiction, not calling initialize() afterward. Tightened the param to Literal["2026-07-28"] (StatelessProtocolVersion) so a 2025-era pin is a type error; the raise stays as the fail-fast for misuse (a no-op would have to synthesize an InitializeResult we don't have).



@requirement("lifecycle:stateless:request-envelope")
async def test_pinned_session_stamps_the_envelope_meta_on_every_request_and_never_initializes() -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't seem very high level? why is it being ran like this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped this file (and the MockTransport one) in 194f225. The capstone in transports/test_hosting_http_modern.py proves everything they did at higher altitude — envelope, headers, no-initialize, and now caller-_meta-preserved via a server-side ctx.meta echo. The initialize()-raises and session-id-ignore checks moved to tests/client/.

maxisbey added 5 commits June 19, 2026 17:48
Review-response changes:
- Merge the duplicate _pinned_version guard in ClientSession.send_request
- Use is_version_at_least() instead of a raw string compare for the
  per-message-headers gate
- Base64-wrap Mcp-Name values with leading/trailing spaces (RFC 7230
  forbids them; h11 rejects on real transports)
- Add a TODO at the Mcp-Name gate naming prompts/get and resources/read
- Type the protocol_version pin as Literal["2026-07-28"] via
  StatelessProtocolVersion so 2025-era values are a type error
- Reword the _related_request_id TODO in ServerSession to point at the
  per-request Outbound shape (not at widening the Protocol)

Interaction-suite consolidation:
- Drop test_lifecycle_stateless.py and test_client_transport_http_modern.py;
  their assertions are now proven by the capstone in
  test_hosting_http_modern.py (envelope, headers, no-initialize) or moved
  to tests/client/ (initialize-raises, session-id-ignore against a
  misbehaving peer)
- Extend the capstone to capture ctx.meta server-side and assert the
  caller-supplied _meta key survives the envelope merge
- Reconcile _requirements.py: stack request-envelope and
  caller-meta-preserved on the capstone; defer no-initialize and
  stateless-ignores-session-id to tests/client/; drop the duplicate
  unpinned-legacy-wire and body-derived-headers entries
- Prove the envelope is stamped when the caller passes no _meta by
  snapshotting the implicit tools/list body in the capstone
- Give _server() an on_meta hook so the capstone reuses it instead of
  duplicating its handlers
- Restore the wire-emptiness check on the pinned-initialize-raises unit
  test (buffer-used == 0 after the raise)
- Restore lifecycle:stateless:unpinned-legacy-wire (deferred) and
  client-transport:http:body-derived-headers (stacked on the capstone)
  in the requirements ledger
- Drop the redundant strip(" ") arg in _encode_header_value
… to a Literal

A pin to a stateful version now flows through initialize() as the
requested version (instead of LATEST_PROTOCOL_VERSION); only a pin to a
2026-07-28+ stateless version raises. The envelope-stamp and
cancel_on_abandon=False in send_request key off the same
is_version_at_least gate. StatelessProtocolVersion is dropped.
…n-version constants

- A 2026-07-28-pinned ClientSession is born initialized: __init__ synthesizes
  an InitializeResult (placeholder server_info until server/discover lands)
  and initialize() returns it idempotently with no wire traffic. A repeat
  initialize() on a stateful session likewise returns the cached result.
  A stateful pin still requests that version on first call.
- Add FIRST_MODERN_VERSION and MODERN_PROTOCOL_VERSIONS to mcp/shared/version
  and import them at the four call sites that previously inlined the literal.
- Move the modern HTTP serving entry from _experimental/ to a private sibling
  module (mcp.server._streamable_http_modern); drop the empty package.
The stateless-era predicate is set membership, not an ordering threshold;
drop FIRST_MODERN_VERSION and is_version_at_least at the four call sites.
The modern entry now takes the protocol_version that matched (threaded
from the manager) instead of hard-coding it.
@maxisbey maxisbey force-pushed the stateless-tools-call branch from dfc49d6 to 47a422c Compare June 20, 2026 09:38
maxisbey added 4 commits June 20, 2026 10:19
… immediately-stale private

- Client gains protocol_version: str | None; threads to ClientSession and
  streamable_http_client. The interaction-suite connect factories forward
  the parameter they already accepted.
- CacheableResult defaults to ttl_ms=0, cache_scope="private" so list/read
  results constructed without explicit hints validate against the 2026-07-28
  surface and never accidentally enable shared caching.
- TRANSPORT_SPEC_VERSIONS era-locks in-memory and streamable-http-stateless
  to 2025-11-25 (the former pending a modern in-memory entry; the latter
  collapses into stateful at the newer revision).
- SPEC_VERSIONS = ("2025-11-25", "2026-07-28"); node ids gain a -version
  suffix suite-wide
- 13 lifecycle:initialize:* / lifecycle:version:* / initialized-notification /
  requests-before-initialized requirements get removed_in="2026-07-28"
  (the handshake does not exist at this revision)
- 10 progress/logging requirements get a KnownFailure at 2026-07-28 (the
  modern entry does not yet stream handler-emitted notifications onto the
  per-request response; burns down once the SSE response mode lands)
- protocol:progress:client-to-server gets a requires-session arm exclusion
  (a bare client->server notification has no route at the stateless entry)
- 6 error-shape requirements get a modern-error-surface arm exclusion (the
  tests pin the legacy code-0/leaked-message divergence; the modern arm
  returns the spec-correct -32603; needs era-aware assertions to re-admit)

[streamable-http-2026-07-28]: 60 pass, 8 xfail.
The ttl_ms=0 / cache_scope="private" defaults make list and read results
validate at the 2026-07-28 surface, so caching, the carried-forward
list/read scenarios, and http-custom-header-server-validation now pass.
tools-call-with-progress stays expected-fail (progress notifications are
not yet delivered on the single-exchange path).
…lope-overwrite semantics

- ClientSession.protocol_version returns the value from the InitializeResult
  once one exists (negotiated for stateful, the pin for stateless via the
  synthesized result), falling back to the pin only before the handshake.
  A stateful pin is the requested version, not a guarantee of the negotiated
  one; inbound validation now keys off what the server actually agreed to.
- Reword lifecycle:stateless:caller-meta-preserved: the three envelope keys
  overwrite caller-supplied values for those keys; non-colliding caller keys
  are preserved. The capstone now passes a colliding key and the wire snapshot
  proves the overwrite.
@maxisbey maxisbey changed the base branch from main to v2-2026-07-28 June 20, 2026 12:06
@maxisbey maxisbey marked this pull request as ready for review June 20, 2026 12:09
@maxisbey maxisbey merged commit 55ee436 into v2-2026-07-28 Jun 20, 2026
31 checks passed
@maxisbey maxisbey deleted the stateless-tools-call branch June 20, 2026 12:10
Comment on lines 158 to +162
def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None:
"""Extract protocol version from initialization response message."""
if self.protocol_version is not None:
# Constructor pin wins over snooping the InitializeResult.
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 When a stateful pin (e.g. Client(url, protocol_version="2025-06-18")) is set, the new early return in _maybe_extract_protocol_version_from_message keeps the transport stamping MCP-Protocol-Version with the pinned value even after the server negotiates a different (older) version — a scenario this PR explicitly supports and tests at the session layer. The spec requires subsequent HTTP requests to carry the negotiated version, and the SDK becomes internally inconsistent (session.protocol_version reports the negotiated value while the wire header reports the pin). Consider gating the pin-wins early return on the pin being in MODERN_PROTOCOL_VERSIONS, where no InitializeResult ever exists.

Extended reasoning...

What the bug is. StreamableHTTPTransport now accepts protocol_version at construction, and _maybe_extract_protocol_version_from_message returns early whenever self.protocol_version is not None ("Constructor pin wins over snooping the InitializeResult"). That early return is correct for the stateless 2026-07-28 pin, where no initialize handshake (and hence no InitializeResult) ever exists. But the same constructor pin is also used for stateful pins: Client.__post_init__ forwards protocol_version unconditionally to streamable_http_client(...), and the interaction-suite connect_over_streamable_http factory does the same. So a client pinned to e.g. 2025-06-18 builds a transport whose snoop is permanently disabled.

The code path that triggers it. ClientSession.initialize() with a stateful pin still runs the handshake, requesting the pinned version. The PR explicitly supports the server answering with a different (older) supported version: test_initialize_on_a_stateful_pin_requests_the_pinned_version has the server respond 2025-03-26 to a 2025-06-18 pin, the session accepts it, and session.protocol_version reports the negotiated 2025-03-26. Meanwhile, in the transport, _handle_json_response / _handle_sse_event call _maybe_extract_protocol_version_from_message for the initialization response — but the pin makes it a no-op, so self.protocol_version stays 2025-06-18. _prepare_headers() then sets MCP-Protocol-Version: 2025-06-18 on every subsequent POST/GET/DELETE.

Step-by-step proof.

  1. Client("https://server/mcp", protocol_version="2025-06-18")__post_init__ calls streamable_http_client(url, protocol_version="2025-06-18")StreamableHTTPTransport(url, protocol_version="2025-06-18"), so transport.protocol_version == "2025-06-18" from construction.
  2. Client.__aenter__ builds ClientSession(..., protocol_version="2025-06-18") and calls initialize(). Since "2025-06-18" not in MODERN_PROTOCOL_VERSIONS, the handshake runs and requests protocolVersion: "2025-06-18".
  3. The server only supports up to 2025-03-26 and answers InitializeResult(protocolVersion="2025-03-26", ...). 2025-03-26 is in SUPPORTED_PROTOCOL_VERSIONS, so the session accepts it; session.protocol_version now returns 2025-03-26 (exactly what the new test asserts).
  4. The transport receives that InitializeResult and calls _maybe_extract_protocol_version_from_message, which returns immediately because self.protocol_version is already "2025-06-18".
  5. Every subsequent request — tools/list, tools/call, the standalone GET stream, the closing DELETE — goes out via _prepare_headers() with MCP-Protocol-Version: 2025-06-18, a version the server never negotiated.

Why nothing else prevents it. The only pin-wins test (test_constructor_pin_is_not_overwritten_by_an_initialize_result) and the constructor docstring ("Required for stateless 2026-07-28 sessions") both target the 2026 stateless pin; nothing covers or pins the stateful behavior. The session layer and the transport layer each track their own version with no reconciliation.

Impact. The streamable-HTTP spec requires the client to send the protocol version negotiated during initialization on subsequent HTTP requests, so the header carries a non-negotiated value — a spec violation against stricter servers, which may reject the requests outright (this SDK's own server only checks the header is a supported version, so the failure mode is interop with third-party servers). Even when nothing rejects, the SDK is internally inconsistent: session.protocol_version says 2025-03-26 while the wire says 2025-06-18.

How to fix. Gate the early return on the pin being a modern (stateless) version, e.g. if self.protocol_version is not None and self.protocol_version in MODERN_PROTOCOL_VERSIONS: return — for stateful pins the snoop should still capture the negotiated version (or, equivalently, let the InitializeResult overwrite the pin). This keeps the stateless 2026-07-28 path untouched while restoring spec-correct headers after a downgrade negotiation. The trigger is narrow (a stateful pin plus a server that downgrades), but the pin plumbing is introduced by this PR, so it is worth fixing here.

Comment on lines +196 to +206
media_type="application/json",
)(scope, receive, send)
return

dispatcher = SingleExchangeDispatcher(request)
# TODO: per-request lifespan re-entry matches stateless_http=True today; revisit in #2893.
async with app.lifespan(app) as lifespan_state:
runner = ServerRunner(
server=app,
dispatcher=dispatcher,
lifespan_state=lifespan_state,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 handle_modern_request validates every POST body strictly as JSONRPCRequest, so a well-formed JSON-RPC notification (no id) is answered with HTTP 400 + a JSONRPCError(PARSE_ERROR) body — the server responds to a notification and mislabels valid JSON as a parse error, while the legacy transport answers the same POST with 202 and no body. Since the pinned client silently swallows the 400, notifications sent via send_progress_notification/send_roots_list_changed on a 2026-07-28 session are dropped invisibly; consider widening the parse to JSONRPCRequest | JSONRPCNotification and answering notifications with 202/no-op (or rejecting them deliberately without a Parse-error envelope).

Extended reasoning...

The bug. In handle_modern_request the request body is parsed with JSONRPCRequest.model_validate_json(body). JSONRPCRequest declares id: RequestId as required (src/mcp/types/jsonrpc.py), so any well-formed JSON-RPC notification — a valid object that simply has no id member — raises ValidationError and falls into the except branch, which answers with HTTP 400 and a JSONRPCError(id=null, code=PARSE_ERROR, message="Parse error") body. Two things are wrong with that: the body is syntactically valid JSON-RPC (it just isn't a request), so -32700 Parse error is a misclassification; and JSON-RPC 2.0 forbids sending a response to a notification, while this path emits a JSON-RPC error envelope for one.\n\nReachability from the SDK's own client. This is not a purely hypothetical peer. A ClientSession pinned to 2026-07-28 still exposes send_notification, send_progress_notification, and send_roots_list_changed, and the pinned StreamableHTTPTransport explicitly supports notifications on this path: _per_message_headers derives Mcp-Method for JSONRPCNotification (and the new transport unit tests pin that behaviour for notifications/cancelled). Because the pinned transport stamps MCP-Protocol-Version: 2026-07-28 on every POST, StreamableHTTPSessionManager.handle_request routes the notification to handle_modern_request, where it hits the 400/Parse-error branch.\n\nStep-by-step proof. (1) Build a pinned client: streamable_http_client(url, protocol_version="2026-07-28") + ClientSession(read, write, protocol_version="2026-07-28"). (2) Call await session.send_progress_notification("tok", 0.5). (3) The transport POSTs {"jsonrpc": "2.0", "method": "notifications/progress", "params": {...}} with MCP-Protocol-Version: 2026-07-28 and Mcp-Method: notifications/progress. (4) The manager sees the modern version header and calls handle_modern_request. (5) JSONRPCRequest.model_validate_json fails on the missing id, so the server returns 400 with {"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "Parse error"}}. (6) On the client, _handle_post_request only emits an error frame for JSONRPCRequest messages when status >= 400, so for a notification the 400 is swallowed and the call returns as if the notification was delivered. By contrast, the legacy streamable-HTTP transport answers the identical notification-only POST with 202 and no body — so this is a behavioural divergence introduced by the new entry, and the loss is invisible to the caller.\n\nWhy nothing else prevents it. The module's TODO ladder covers Accept-header validation and GET/DELETE rejection, not notification bodies, and the interaction-suite exclusion (protocol:progress:client-to-server excluded at 2026-07-28 with reason requires-session) only removes the test, it does not make the wire response correct. The docstring's "one JSON-RPC request in, one JSON-RPC response out" describes the happy path, not a decision to answer notifications with a Parse-error envelope.\n\nOn the counter-argument. One reviewer noted that the streamable-HTTP transport spec permits an HTTP error status (optionally with an id-less JSON-RPC error body) when the server cannot accept a notification POST, and that client-originated notifications are arguably out of scope for this slice. Even granting both points, the response is still wrong on its own terms: -32700 Parse error asserts the body could not be parsed, which is false (the appropriate shape would be an Invalid Request / deliberate not-supported rejection, or simply a 202 no-op as the legacy path does), and the silent client-side swallow means real SDK calls (send_progress_notification, send_roots_list_changed) disappear with no signal anywhere. The fix is small and local — widen the parse to JSONRPCRequest | JSONRPCNotification and answer notifications with 202/no body (matching legacy and the dispatcher's existing notify() no-op), or return a deliberate, correctly-coded rejection.\n\nImpact and fix. Without a change, any 2026-07-28 deployment that uses progress or roots-changed notifications loses them silently and emits a spec-questionable wire response that other-SDK clients may log or mishandle. Suggested fix in handle_modern_request: try JSONRPCRequest, fall back to JSONRPCNotification; for a notification, return Response(status_code=202) (the SingleExchangeDispatcher.notify path already no-ops, pending the SSE response mode), reserving the 400/Parse-error answer for genuinely unparseable bodies.

Comment thread src/mcp/client/client.py
Comment on lines 112 to 118
if isinstance(self.server, Server | MCPServer):
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions)
elif isinstance(self.server, str):
self._transport = streamable_http_client(self.server)
self._transport = streamable_http_client(self.server, protocol_version=self.protocol_version)
else:
self._transport = self.server

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pinning protocol_version="2026-07-28" on a Client constructed with a Server/MCPServer instance silently produces a broken session: the pin only flows into streamable_http_client for URL servers, while the in-memory transport still drives the legacy stateful server, so the born-initialized session never sends initialize/notifications/initialized and every call fails with an opaque "Invalid request parameters" error. Since the in-memory modern entry is out of scope for this PR, consider a constructor-time fail-fast (raise when a MODERN_PROTOCOL_VERSIONS pin is combined with a non-URL server) or a docstring restriction on the new protocol_version field.

Extended reasoning...

What happens

Client.__post_init__ (src/mcp/client/client.py:112-118) only forwards protocol_version into streamable_http_client(...) when server is a URL string. When server is a Server/MCPServer instance it builds InMemoryTransport(self.server, ...) with no version awareness — but __aenter__ still passes protocol_version into ClientSession. With a 2026-07-28 pin that session is "born initialized": initialize() returns the locally-synthesized result without ever touching the wire, and no notifications/initialized is sent.

Why every call then fails

InMemoryTransport._connect runs the lowlevel server via actual_server.run(...) with the default stateless=False, so the legacy init gate in ServerRunner._on_request applies: any non-ping request before initialize/notifications/initialized is rejected with MCPError(INVALID_PARAMS, "Invalid request parameters"). The per-request io.modelcontextprotocol/protocolVersion envelope the pinned session stamps does not help — 2026-07-28 is not in SUPPORTED_PROTOCOL_VERSIONS, and the init gate is independent of version resolution anyway.

Step-by-step proof

  1. server = MCPServer("test") with an add tool (the exact pattern in the Client class docstring example).
  2. async with Client(server, protocol_version="2026-07-28") as client:__post_init__ wraps server in InMemoryTransport (no version pin); __aenter__ builds a stateless-pinned ClientSession and calls initialize(), which returns the synthesized result without sending any frame. The context manager enters successfully.
  3. await client.call_tool("add", {"a": 1, "b": 2}) — the request reaches the lowlevel server's ServerRunner._on_request; connection.initialize_accepted is false (no handshake ever happened), so the gate raises INVALID_PARAMS and the user sees MCPError: Invalid request parameters — an error that points nowhere near the actual cause (an unsupported transport/version combination chosen at construction).

Why nothing prevents it today

Nothing in Client.__post_init__ rejects or warns about the combination, and the new Client.protocol_version docstring describes the stateless behaviour generically ("Pinning to 2026-07-28 or later selects the stateless transport era...") with HTTP only mentioned as an additional detail — while the class's own primary docstring example is exactly the in-memory MCPServer case. The PR description lists the in-memory modern entry as out of scope, and the interaction suite era-locks in-memory to 2025-11-25 in TRANSPORT_SPEC_VERSIONS, so the gap is known internally — but the public API surface gives the user no signal.

Impact and suggested fix

This is not silent corruption — the first call fails loudly — but the failure is confusing and far removed from the cause, on a brand-new public parameter whose docstring invites exactly this usage. A cheap, non-breaking guard fixes it: in __post_init__, raise (e.g. ValueError) when protocol_version in MODERN_PROTOCOL_VERSIONS and server is a Server/MCPServer instance (or, more conservatively, any non-URL transport), with a message pointing at the missing in-memory modern entry. Alternatively, restrict the pin to URL/HTTP servers in the protocol_version docstring until the in-memory modern entry lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant